Skip to content

feat(contexts): give applications a menu with its own API - #3135

Open
rubenvdlinde wants to merge 2 commits into
feat/noid/grid-pages-apifrom
feat/noid/app-menu-model
Open

rubenvdlinde wants to merge 2 commits into
feat/noid/grid-pages-apifrom
feat/noid/app-menu-model

Conversation

@rubenvdlinde

@rubenvdlinde rubenvdlinde commented Oct 22, 2026 •

Copy link
Copy Markdown

Part of the stack on #3133. Implements the menu proposal on #3100: the model and the API. Export and import follow in #3138. This replaces the earlier version of this pull request, together with #3136 and #3137.

Size: 3322 lines: 909 code, 414 tests, 1991 generated OpenAPI and 8 version.

Agreed on #3100

  • No technical name on menu items. An export refers to an item's group by its position in the exported menu.
  • Three target columns (page_id, node_rel_id, url), of which at most one is set. The service enforces it; the rule is in the entity docblock.
  • Remnants: deleting a target removes its menu items in the same transaction; creating or changing an item checks its target in its own transaction; reading still leaves out an item whose target is missing, as a safety net. No background job.
  • Empty groups stay in the data. Editors see them, readers without manage access don't.
  • The application id is contextId, like the other application routes, not context as in the proposal.

What changes

  • A menu item opens at most one thing: a page of the application ({pageId}), a table or view of the application by its node relation ({nodeRelId}), or an http or https link ({url}). An item that opens nothing is a group. Groups are top-level and only groups hold items, so a menu is one level deep.
  • An item without a title shows the title of what it opens: the page, the table or view, or the link. The start page without a title shows the name of the application. The API returns both title (as stored) and displayTitle.
  • A new application gets one item, without a title, for its start page. It's an ordinary item. Applications that exist today have no items, and an application without items opens its start page, so there's no data migration.
  • Cleanup: an item goes with its target, in the same transaction: when its page is deleted, when its table or view leaves the application (through the application's nodes, a scheme import, or because the table or view is deleted, also with its owner), and with the application. The items of a deleted group move to the top level.
  • At most 200 items per application.
  • The application response stays as it is on main. Permission checks and the application response don't load the menu.
  • Endpoints on api/2, with #[ApiRoute]:
    • GET /api/2/menu?contextId={id} returns the menu as a flat list in display order, with parentId
    • POST /api/2/menu adds an item to the end: contextId, title, icon, description, target
    • PATCH /api/2/menu/{itemId} changes an item; a target replaces the previous one, a group that holds items can't get one
    • DELETE /api/2/menu/{itemId} deletes an item
    • PUT /api/2/menu/order takes contextId and items: [{id, parentId}] in display order. It lists every item exactly once, a parent is a top-level group listed before its items, and it saves all of it or nothing ("changed in between, reload"). It returns the whole menu.
  • Permissions: reading needs read access, changes need manage access, checked once through the item's application. Outsiders and items of another application get a 404, readers who try a change a 403, a target outside the application a 400. #[RequirePermission] covers the endpoints with contextId; the item endpoints check through the item's application.
  • Rate limits like pages, with #[UserRateLimit]: create 20 a minute, change and order 60 a minute.

How it was tested

On Nextcloud 34 with PostgreSQL:

  • MenuServiceTest against the database: the start page item of a new application, title fallback for every kind of target, invalid items (data provider), at most one target, empty groups hidden from readers, the 200-item cap, PATCH semantics, a group with items can't get a target, order and grouping, deleting a group, invalid orders that save nothing (data provider), cleanup with the page, the node and the application, a dangling item left out, and only managers change the menu (data provider)
  • MenuControllerTest: arguments passed on and every error mapped to its status (data providers)
  • the full unit suite on PostgreSQL and SQLite: 601 tests
  • composer cs:check, psalm and rector, all clean

🖼️ Screenshots

Backend only, no UI changes.

🏁 Checklist

  • ⛑️ Tests (unit and/or integration) are included or not needed
  • 🔙 Backport requests are created or not needed: /backport to stableX.X
  • 📅 Milestone is set
  • 🌸 PR title is meaningful (if it should be in the changelog: is it meaningful to users?)

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI

@rubenvdlinde
rubenvdlinde added this pull request to stack #3139 October 22, 2026 11:42
@rubenvdlinde
rubenvdlinde force-pushed the feat/noid/app-menu-model branch from 9847aa9 to 5825db2 Compare October 22, 2026 13:25
@rubenvdlinde
rubenvdlinde force-pushed the feat/noid/app-menu-model branch from 5825db2 to 9e81a78 Compare October 23, 2026 08:05
@rubenvdlinde
rubenvdlinde force-pushed the feat/noid/app-menu-model branch 2 times, most recently from 7e236cc to c7a6e95 Compare October 26, 2026 09:24
@rubenvdlinde
rubenvdlinde marked this pull request as ready for review October 26, 2026 09:25
return null;
}

$table = $schema->createTable(self::TABLE);

@enjeck enjeck Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add the menu's data model to #3100 first, so we agree on it like we did for pages and widgets? #3099 only describes a menu of pages like a start page item that people can rename, move or remove. Items for tables, views and links are new to me

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The proposal is on #3100 and blizzz has answered it. This PR is rebuilt on that proposal: one table, tables_contexts_menu_item, created by a single migration.

$table->addColumn('icon', Types::STRING, ['notnull' => false, 'length' => 64]);
$table->addColumn('description', Types::TEXT, ['notnull' => false]);
$table->addColumn('target_type', Types::STRING, ['notnull' => true, 'length' => 16]);
$table->addColumn('target_id', Types::INTEGER, ['notnull' => false]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could menu items use node_rel_id, like widgets? Then removing a table can also remove its menu item

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. A table or view item now points at its node_rel_id. When the table or view leaves the application, its items are removed in the same transaction as its widget sources.

Comment thread lib/Db/ContextMapper.php Outdated
}

return $this->formatResultRows($r, $userId);
return $this->formatResultRows($r, $userId, $this->menuItemMapper->findByContextIds([$contextId]));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Permission checks use findById(), so each check now runs this menu query for no reason. Maybe should be conditional

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that query is gone. The menu is no longer loaded with the application. It has its own endpoint, GET /api/2/menu?contextId=, so findById() and the permission checks don't touch it at all.

Comment thread lib/Service/ContextService.php Outdated
Comment on lines +368 to +373
// Deleting the application first takes the write lock before anything is read, so on
// SQLite a concurrent write fails this transaction at once and the retry runs it again.
$this->atomicRetry(function () use ($context): void {
$this->contextMapper->delete($context);
$this->shareService->deleteAllForContext($context);
$this->menuItemMapper->deleteAllByContextId($context->getId());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be in a different PR?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. That change came out of this PR. All that's left in ContextService is what the menu needs: a new application gets one item for its start page, and deleting an application or one of its tables or views removes the items that open it.

rubenvdlinde and others added 2 commits November 4, 2026 11:30
An application gets a menu of items. An item opens at most one thing: a
page of the application, a table or view of the application by its node
relation, or an http or https link. An item that opens nothing is a
group; groups are top-level and only groups hold items, so a menu is one
level deep. An item without a title shows the title of what it opens.
An item has no technical name of its own. A menu holds at most 200
items.

The menu has its own endpoints on api/2: GET /menu?contextId= returns the
menu as a flat list in display order, POST /menu adds an item to the
end, PATCH and DELETE /menu/{itemId} change or delete one item, and
PUT /menu/order saves the order and grouping of the whole menu in one
transaction. Reading needs read access to the application and changes
need manage access, checked once through the item's application.
Outsiders get a 404, readers who try a change a 403 and a target outside
the application a 400. Create is limited to 20 a minute per user, a
change or an order to 60.

Of the three target columns (page, node relation, link) at most one is
set, which the service enforces. Creating or changing an item checks its
target in the transaction that saves it. Empty groups stay in the menu;
readers without manage access don't see them.

A new application gets one item, without a title, for its start page.
An item goes when its target goes, in the same transaction: with its
page, when its table or view leaves the application or is deleted, and
with the application. The items of a deleted group move to the top
level. An item whose target is gone anyway is left out when the menu is
read. Applications that exist today have no items and open their start
page.

Co-authored-by: remko48 <remko@conduction.nl>
Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Ruben van der Linde <ruben@conduction.nl>
Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Ruben van der Linde <ruben@conduction.nl>
@rubenvdlinde
rubenvdlinde removed this pull request from stack #3139 November 4, 2026 10:41
@rubenvdlinde
rubenvdlinde force-pushed the feat/noid/app-menu-model branch from 7d89fa5 to aa0c7e3 Compare November 4, 2026 10:42
@rubenvdlinde rubenvdlinde changed the title feat(contexts): give applications a menu feat(contexts): give applications a menu with its own API Nov 5, 2026
@rubenvdlinde
rubenvdlinde added this pull request to stack #3222 November 5, 2026 09:14
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Hello there,
Thank you so much for taking the time and effort to create a pull request to our Nextcloud project.

We hope that the review process is going smooth and is helpful for you. We want to ensure your pull request is reviewed to your satisfaction. If you have a moment, our community management team would very much appreciate your feedback on your experience with this PR review process.

Your feedback is valuable to us as we continuously strive to improve our community developer experience. Please take a moment to complete our short survey by clicking on the following link: https://cloud.nextcloud.com/apps/forms/s/i9Ago4EQRZ7TWxjfmeEpPkf6

Thank you for contributing to Nextcloud and we hope to hear from you soon!

(If you believe you should not receive this message, you can add yourself to the blocklist.)

@rubenvdlinde
rubenvdlinde marked this pull request as ready for review November 6, 2026 08:24
@rubenvdlinde
rubenvdlinde requested a review from enjeck November 6, 2026 08:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants